S259: VSCode Extension Native Git Operations Tab

Documentation for Plans

S259: VSCode Extension — Native Git Operations Tab

Session: S259.3 Status: Plan — awaiting approval Priority: High — brings full git visibility into the IDE for all platform users Depends on: S254 (clone URLs), S251 (git-operations library)


Summary

Add a "Repositories" tab to the Vitto VSCode extension that natively renders the git-graph, branch sidebar, file browser, commit list, server metrics, and clone-to-workspace flow — all powered by the existing @libs-structural/git-operations library. Role-based access controls which repos a user sees and what actions they can perform.


Architecture

Data Flow

Extension Webview (Lit)
    ↓ postMessage
Extension Host (Node.js)
    ↓ HTTP (Authorization: Bearer JWT)
backend-ai-api:3001
    ├── GET /ai-api/git/repos         → native fetch → sandbox-orchestrator /git/repos
    ├── GET /ai-api/git/repos/:s/:u/* → native fetch → sandbox-orchestrator /git/repos/:s/:u/*
    └── spaceId resolution            → HttpService  → backend-api:3000 /api/spaces/:id

Why This Shape

  1. Extension talks to one backend (ai-api). No infra topology leaks.
  2. ai-api already has SANDBOX_SERVER_URL + SANDBOX_API_KEY (SelfHostedProvider) and BACKEND_API_URL + BACKEND_API_INTERNAL_TOKEN (PlatformOrchestrationService). Zero new env vars.
  3. JWT already carries platformRole + spaceId. Role resolution and space filtering are free — no extra HTTP calls from the extension.
  4. Pure-TS utils import directly into the extension via esbuild path aliases (already configured in esbuild.mjs). No Angular dependency.

What Gets Reused vs. Rewritten

Layer Reuse Rewrite
Types (git-operations.types.ts) Direct import —
Role config (git-operations-role.config.ts) Direct import —
Template paths config Direct import —
Format utils (shortenSha, timeAgo, etc.) Direct import —
Lane assignment (assignLanes, computeCommitAge) Direct import —
SVG renderer (generateRowSvg, computeSvgWidth) Direct import —
7 Angular components — Port to Lit
GitOperationsApiService — Replace with extension host HTTP handler
GitOperationsStateService — Replace with Lit reactive state in webview
UxPortalService (settings modal) — Replace with Lit dialog
ContextMenuDirective — Replace with vanilla JS right-click

Backend: GitProxyController + GitProxyService

New Files

APIs/backend-ai-api/src/app/git-proxy/
├── git-proxy.module.ts
├── git-proxy.controller.ts
└── git-proxy.service.ts

GitProxyService

Follows SelfHostedProvider pattern exactly:

@Injectable()
export class GitProxyService implements OnModuleInit {
  private baseUrl: string | null = null;
  private apiKey: string | null = null;
  private backendApiUrl: string;
  private systemToken: string;

  constructor(
    private readonly config: ConfigService,
    private readonly httpService: HttpService,
  ) {}

  onModuleInit() {
    // Orchestrator (native fetch)
    this.baseUrl = this.config.get<string>('SANDBOX_SERVER_URL', '') || null;
    this.apiKey = this.config.get<string>('SANDBOX_API_KEY', '') || null;
    // Backend-api (HttpService)
    this.backendApiUrl = this.config.get<string>('BACKEND_API_URL') || 'http://localhost:3000/api';
    this.systemToken = this.config.get<string>('BACKEND_API_INTERNAL_TOKEN')
      || this.config.get<string>('SYSTEM_TOKEN') || '';
  }

  // Proxy to orchestrator: native fetch + X-API-Key
  async listRepos(): Promise<GitRepoInfo[]> { ... }
  async getRepoBranches(space, universe): Promise<GitBranchInfo[]> { ... }
  async getRepoCommits(space, universe, ref, limit): Promise<GitCommitInfo[]> { ... }
  async getCommitDiff(space, universe, sha): Promise<GitCommitDiff> { ... }
  async getFileTree(space, universe, ref): Promise<GitFileTreeItem[]> { ... }
  async getFileContent(space, universe, filePath, ref): Promise<GitFileContent> { ... }
  async getCloneUrls(space, universe): Promise<{ ssh, https, repoPath }> { ... }
  async getHostMetrics(): Promise<HetznerHostMetrics | null> { ... }

  // Resolve spaceId → spaceSlug via backend-api
  async resolveSpaceSlug(spaceId: string): Promise<string | null> { ... }
}

GitProxyController

@Controller('git')
@UseGuards(UnifiedAuthGuard)
export class GitProxyController {
  // GET /ai-api/git/repos
  // - Reads req.user.platformRole → resolveGitOpsTier()
  // - If user tier: resolves req.user.spaceId → spaceSlug, filters repos
  // - If admin+: returns all repos
  // - Attaches roleTier + features to response

  // GET /ai-api/git/repos/:space/:universe/branches
  // GET /ai-api/git/repos/:space/:universe/commits
  // GET /ai-api/git/repos/:space/:universe/commits/:sha
  // GET /ai-api/git/repos/:space/:universe/tree
  // GET /ai-api/git/repos/:space/:universe/file
  // GET /ai-api/git/repos/:space/:universe/clone-urls
  // GET /ai-api/git/metrics
  // - All validate space access based on role tier
  // - Forward to orchestrator via GitProxyService
}

Space Access Validation

private async validateSpaceAccess(caller: UnifiedCaller, spaceSlug: string): Promise<boolean> {
  const tier = resolveGitOpsTier(caller.platformRole);
  if (['executive', 'developer', 'admin'].includes(tier)) return true;
  // User tier: resolve their spaceId → slug, compare
  const userSpaceSlug = await this.gitProxyService.resolveSpaceSlug(caller.spaceId);
  return userSpaceSlug === spaceSlug;
}

Response Shape for /ai-api/git/repos

{
  repos: GitRepoInfo[],        // filtered by role
  roleTier: GitOpsRoleTier,    // 'executive' | 'developer' | 'admin' | 'user'
  features: GitOpsFeatureFlags // full feature flag set for this tier
}

Registration

Add GitProxyModule to AppModule.imports in app.module.ts. Module imports HttpModule + ConfigModule.


Extension Host: Git Operations Handler

New File

apps/vitto-vscode-extension/src/services/git-operations.handler.ts

Responsibilities

Message Types (added to types.ts)

// Webview → Extension Host
| { type: 'gitListRepos' }
| { type: 'gitSelectRepo'; space: string; universe: string }
| { type: 'gitSwitchBranch'; space: string; universe: string; branch: string }
| { type: 'gitGetCommitDiff'; space: string; universe: string; sha: string }
| { type: 'gitOpenFile'; space: string; universe: string; filePath: string; ref: string }
| { type: 'gitCloneToWorkspace'; url: string }
| { type: 'gitGetCloneUrls'; space: string; universe: string }
| { type: 'gitGetMetrics' }

// Extension Host → Webview
| { type: 'gitReposLoaded'; repos: GitRepoInfo[]; roleTier: string; features: GitOpsFeatureFlags }
| { type: 'gitRepoSelected'; branches: GitBranchInfo[]; files: GitFileTreeItem[]; commits: GitCommitInfo[]; allCommits: GitCommitInfo[]; cloneUrls: { ssh: string; https: string; repoPath: string } }
| { type: 'gitBranchSwitched'; files: GitFileTreeItem[]; commits: GitCommitInfo[] }
| { type: 'gitCommitDiffLoaded'; diff: GitCommitDiff }
| { type: 'gitFileContentLoaded'; content: string; path: string }
| { type: 'gitCloneUrlsLoaded'; ssh: string; https: string; repoPath: string }
| { type: 'gitMetricsLoaded'; metrics: HetznerHostMetrics }
| { type: 'gitError'; message: string }

Extension Webview: "Repositories" Tab

New Files

apps/vitto-vscode-extension/src/panels/webview/components/repositories/
├── repositories-view.ts          # Main tab view (orchestrator)
├── repo-card.ts                  # Individual repo card
├── repo-detail.ts                # Detail view (graph + files + commits)
├── git-graph-lit.ts              # Git graph (SVG, lane legend, clone dropdown)
├── branch-sidebar-lit.ts         # Branch list + CRUD
├── file-browser-lit.ts           # File tree grouped by directory
├── commit-list-lit.ts            # Flat commit list
├── commit-detail-panel-lit.ts    # Slide panel with diff stats
├── server-metrics-lit.ts         # Metrics strip (gated by role)
└── context-menu-lit.ts           # Shared vanilla JS context menu

Tab Registration

  1. types.ts: Add 'repositories' to TabId union
  2. app.ts: Add import, tab entry ({ id: 'repositories', label: 'Repositories', icon: 'source-control' }), and render slot
  3. state.ts: Add git operations state fields to AppState
  4. api.ts: Add message handlers and API functions

State Shape (added to AppState)

// Git operations state
gitRepos: GitRepoInfo[];
gitRoleTier: GitOpsRoleTier;
gitFeatures: GitOpsFeatureFlags | null;
gitSelectedRepo: GitRepoInfo | null;
gitBranches: GitBranchInfo[];
gitFiles: GitFileTreeItem[];
gitCommits: GitCommitInfo[];          // current branch
gitAllCommits: GitCommitInfo[];       // --all (for graph)
gitCloneUrls: { ssh: string; https: string; repoPath: string } | null;
gitSelectedBranch: string;
gitDetailTab: 'graph' | 'files' | 'commits';
gitExpandedCommitSha: string | null;
gitCommitDiff: GitCommitDiff | null;
gitFileContent: string | null;
gitSelectedFilePath: string | null;
gitLoading: boolean;
gitDetailLoading: boolean;
gitHostMetrics: HetznerHostMetrics | null;
gitSearchQuery: string;
gitError: string | null;

Component Hierarchy (mirrors gograb-dev page exactly)

repositories-view
├── [overview mode: !selectedRepo]
│   ├── server-metrics-lit (gated: features.showServerMetrics)
│   ├── stats strip (totalRepos, totalBranches, totalFiles)
│   ├── search input
│   └── repo-card grid (filtered by search + role)
│       └── repo-card × N
│           ├── space/universe header
│           ├── branch count, file count, last commit
│           └── delete button (gated: features.canDeleteRepos)
│
└── [detail mode: selectedRepo]
    ├── back button
    ├── tab bar: Graph | Files | Commits
    ├── branch-sidebar-lit (left)
    │   ├── branch list with switch/merge/delete actions
    │   └── create branch form (gated: features.canManageBranches)
    └── main content area
        ├── [graph tab]
        │   └── git-graph-lit
        │       ├── lane legend (branch pills, focus on click)
        │       ├── clone dropdown (SSH/HTTPS copy + "Clone to Workspace" button)
        │       ├── settings popover (show all branches, author, timestamp)
        │       └── SVG graph rows (importable generateRowSvg)
        ├── [files tab]
        │   └── file-browser-lit
        │       ├── template sources toggle (gated: features.canViewTemplateSources)
        │       └── grouped file list with icons
        └── [commits tab]
            └── commit-list-lit
                ├── flat commit list
                └── inline expandable diff

Clone-to-Workspace Flow (Inside Git Graph)

The clone dropdown lives inside git-graph-lit, matching the existing Angular component's design.

UX Flow

1. User clicks "Clone ▾" button in git-graph header
2. Dropdown shows:
   ┌────────────────────────────────────────────┐
   │  SSH                                        │
   │  ┌──────────────────────────────┬────────┐ │
   │  │ [email protected]:... │  Copy  │ │
   │  └──────────────────────────────┴────────┘ │
   │  HTTPS                                      │
   │  ┌──────────────────────────────┬────────┐ │
   │  │ https://sandbox.gograb...    │  Copy  │ │
   │  └──────────────────────────────┴────────┘ │
   │                                             │
   │  ┌─────────────────────────────────────┐   │
   │  │  ↓ Clone to Workspace               │   │
   │  └─────────────────────────────────────┘   │
   │  Opens VSCode's native git clone dialog    │
   └────────────────────────────────────────────┘

3. "Copy" → navigator.clipboard.writeText(url)
4. "Clone to Workspace" → postMessage({ type: 'gitCloneToWorkspace', url: sshUrl })
   → Extension host:
     a) Scan open workspace folders for matching git remote URL
     b) If found: vscode.window.showInformationMessage(
          'Already cloned at /path', 'Add to Workspace', 'Open Folder')
     c) If not: vscode.commands.executeCommand('git.clone', url)
        → Native VSCode clone: folder picker → progress → auto-open

Smart Detection (Extension Host)

async function findExistingClone(remoteUrl: string): Promise<string | null> {
  const gitExtension = vscode.extensions.getExtension('vscode.git');
  if (!gitExtension) return null;
  const git = gitExtension.exports.getAPI(1);
  for (const repo of git.repositories) {
    for (const remote of repo.state.remotes) {
      if (remote.fetchUrl === remoteUrl || remote.pushUrl === remoteUrl) {
        return repo.rootUri.fsPath;
      }
    }
  }
  return null;
}

VSCode Theme Integration

All colors use --vscode-* CSS custom properties instead of hardcoded Catppuccin values:

Hardcoded VSCode Var
#11111b (BG) --vscode-editor-background
#cdd6f4 (text) --vscode-editor-foreground
#313244 (border) --vscode-panel-border
#1e1e2e (surface) --vscode-sideBar-background
#f38ba8 (error) --vscode-errorForeground
#a6e3a1 (success) --vscode-testing-iconPassed

Lane colors (LANE_COLORS) are kept as-is — they need to be distinct and consistent regardless of theme. The SVG renderer's BG constant is parameterized to use the CSS var value.


Role-Based Access Matrix

Feature Executive Developer Admin User
See all repos (all spaces) Yes Yes Yes No (own space only)
Git graph Yes Yes Yes Yes
Branch CRUD Yes Yes Yes No
Merge branches Yes Yes No No
Delete repos Yes Yes No No
View files Yes Yes Yes Yes
Commit files Yes Yes No No
Server metrics Yes Yes No No
Template sources toggle Yes Yes No No
Clone to workspace Yes Yes Yes Yes
Commit history Yes Yes Yes No

Implementation Order

Phase 1: Backend Proxy (ai-api)

  1. Create git-proxy.service.ts — native fetch to orchestrator + space resolution
  2. Create git-proxy.controller.ts — REST endpoints with role-based filtering
  3. Create git-proxy.module.ts — register in AppModule
  4. Verify: curl test all endpoints with JWT

Phase 2: Extension Host Handler

  1. Create git-operations.handler.ts — HTTP client to ai-api/git/*
  2. Add message types to types.ts
  3. Wire message handlers in main-panel.ts
  4. Add clone-to-workspace logic (git API integration)

Phase 3: Webview Core (Lit)

  1. Add 'repositories' tab to types.ts, app.ts, state.ts
  2. Create repositories-view.ts — main orchestrator component
  3. Create repo-card.ts — repo grid cards
  4. Create server-metrics-lit.ts — metrics strip

Phase 4: Detail View (Lit)

  1. Create repo-detail.ts — detail layout with tab bar
  2. Create branch-sidebar-lit.ts — branch list + actions
  3. Create git-graph-lit.ts — SVG graph with lane legend + clone dropdown
  4. Create commit-list-lit.ts — flat commit list with expandable diff
  5. Create file-browser-lit.ts — grouped file tree
  6. Create commit-detail-panel-lit.ts — slide panel
  7. Create context-menu-lit.ts — shared right-click menu

Phase 5: Clone Integration

  1. Wire clone dropdown in git-graph to extension host
  2. Implement smart detection (scan workspace git remotes)
  3. Test full flow: discover repo → clone → add to workspace

Phase 6: Polish

  1. VSCode theme CSS variable integration
  2. Loading states, error states, empty states
  3. Extension package.json: add command vitto.showRepositories
  4. Test with different role tiers

Risks and Mitigations

Risk Impact Mitigation
esbuild can't resolve @libs-structural/git-operations imports Blocks all util reuse Verify path alias in esbuild.mjs resolves correctly. Fallback: copy utils into extension src.
SVG innerHTML in Lit triggers CSP violation Graph won't render Use unsafeHTML directive from lit/directives/unsafe-html.js. CSP nonce already configured in main-panel.ts.
navigator.clipboard.writeText fails in webview Copy buttons broken VSCode webviews support clipboard API. Fallback: postMessage to host, host does vscode.env.clipboard.writeText().
Large repo list (50+ repos) slows QuickPick Poor UX for executives Add server-side pagination to /ai-api/git/repos. For MVP, 50 repos is fine.
JWT missing spaceId claim (old tokens) User-tier filtering fails Controller returns 401 with clear message: "Please sign out and sign in again."

Out of Scope


Verification Criteria

  1. User with developer role sees all repos across all spaces
  2. User with user role sees only their space's repos
  3. Git graph renders correctly with lane colors and merge curves
  4. Clone dropdown copies URLs and triggers VSCode native clone
  5. Smart detection finds already-cloned repos in workspace
  6. Branch CRUD works for authorized roles
  7. File browser groups files by directory with template filtering
  8. Commit list expands to show diff stats
  9. Server metrics strip appears only for executive/developer
  10. All UI respects VSCode light/dark/high-contrast themes